feat: implement TypeScript Connect API client#3673
Conversation
Replace the stub TypeScriptDirectClient with a full implementation that makes HTTP requests directly to the Connect server, mirroring the Go client's behavior. All 15 API methods are implemented and pass the same 68 contract tests that validate the Go client. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move the TypeScript Connect API client from the contract test file into a standalone @posit-dev/connect-client package with proper types, error classes, and barrel exports. The contract test adapter becomes a thin wrapper that imports from the new package. All 68 contract tests pass with both Go and TypeScript backends. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a matrix strategy to the contract-tests job so it runs once with API_BACKEND=go (existing behavior) and once with API_BACKEND=typescript (new ConnectClient from @posit-dev/connect-client). The TypeScript backend skips Go setup since it doesn't need the harness binary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| "dependencies": { | ||
| "@posit-dev/connect-client": "file:../../packages/connect-client" | ||
| }, |
There was a problem hiding this comment.
If we continue down this path (which I think we should) we should consider doing a few things:
- create a shared
tsconfigby creating a TypeScript configuration package. The Turborepo docs have some ideas on how to do this. This will prevent us from duplicatingtsconfig.jsonsetups everywhere.- We actually have an old issue for this: Setup shared TypeScript configurations for monorepo packages #1295
- we should also consider using
npm workspaces: Setup npm workspaces for shared code #1291- We can wait until we ditch Go, and can raise the extension to the top level of the repo
I think its a good idea to have separate packages. It makes the way we import and deal with some of the code around the homeView much easier to deal with. Right now it imports types from extension/vscode by importing with ../../.. which is not great.
There was a problem hiding this comment.
@dotNomad would you say creating a shared configuration package should be part of this PR, or part of follow-up work?
There was a problem hiding this comment.
Follow-up for sure. Just mentioning it here to spread awareness
packages/connect-api/src/client.ts
Outdated
| // HTTP helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| private async request( |
There was a problem hiding this comment.
I would prefer to keep using axios despite some of the problems we've had so we don't have to write our own error handling logic, or change how we do it in a lot of spots. That would simplify what we have here quite a bit.
packages/connect-api/src/client.ts
Outdated
| return { | ||
| id: dto.guid, | ||
| username: dto.username, | ||
| first_name: dto.first_name, | ||
| last_name: dto.last_name, | ||
| email: dto.email, | ||
| }; |
There was a problem hiding this comment.
This is a preference in design, but in my opinion it has a lot of benefits:
I would prefer to return the exact thing the API call responds with, including all data, and not creating new objects.
This will keep this code extremely single use and the only thing we will need to adjust overtime are the types. We won't have to adjust the functions since they just pass the response right through. We can always extract the data from the response.
It is much more flexible.
You can read more in our README.md for the Go API client we wrote: https://github.com/posit-dev/publisher/blob/183abd7d744fb8f6d8bcffc323717547521eca57/extensions/vscode/src/api/README.md
There was a problem hiding this comment.
I see this is mapping a UserDTO to a User, but I think we should avoid this. It would make the initial migration easier, but once it is complete it will be much more difficult to understand why the attributes are different.
Down to discuss temporary trade-offs.
| export type ContentID = string & { readonly __brand: "ContentID" }; | ||
| export type BundleID = string & { readonly __brand: "BundleID" }; | ||
| export type TaskID = string & { readonly __brand: "TaskID" }; | ||
| export type UserID = string & { readonly __brand: "UserID" }; | ||
| export type GUID = string & { readonly __brand: "GUID" }; |
There was a problem hiding this comment.
Branded Types are a nice addition to avoid ID overlap 👍
Rename the package from @posit-dev/connect-client to @posit-dev/connect-api and add comprehensive unit tests for the ConnectClient class and error types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Also renames ConnectClientOptions to ConnectAPIOptions and ConnectClientError to ConnectAPIError for consistency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace native fetch with axios for HTTP requests in the ConnectAPI class. Uses axios.create() with baseURL, default Authorization header, and validateStatus: () => true to preserve existing error handling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stop constructing new objects in client methods — return the exact response data from the Connect API instead. Removes the User type (a client-side invention) and updates the contract test adapter to extract fields from full DTOs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| }; | ||
| } | ||
|
|
||
| private async dispatch( |
There was a problem hiding this comment.
once we get rid of the go API client, we can clean up the shape of this stub, or just use the ConnectAPI client directly in test.
Right now the test shape is coupled to the go client's shape so we can have an apples-to-apples comparison in the contract tests, but we won't always need to have the apples-to-apples comparison we're running here.
There was a problem hiding this comment.
Should we add a clean-up comment for more inline-context?
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // getSettings |
There was a problem hiding this comment.
I could be convinced that the right approach will end up being splitting these out into atomic calls - maybe something we can feel out as we go along.
| * Validates credentials and checks user state (locked, confirmed, role). | ||
| * Returns the full UserDTO on success; throws AuthenticationError otherwise. | ||
| */ | ||
| async testAuthentication(): Promise<UserDTO> { |
There was a problem hiding this comment.
not strictly a light wrapper around an API call, but this does seem pretty useful to have on the API client.
|
|
||
| /** | ||
| * Fetches composite server settings from 7 separate endpoints, | ||
| * mirroring the Go client's GetSettings behavior. |
There was a problem hiding this comment.
Flagging again - not sure if we want to mimic this or not
There was a problem hiding this comment.
I don't think so, but I'm fine starting with it to make migrations easier. Eventually I think it would be better to split this up.
We definitely should not do an await cascade like this though. We should make all of these calls in parallel with Promise.all() or something similar.
Right now this will wait for each individual API call to complete before making the next one severely slowing down this fetch.
| // Integration type | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| export interface Integration { |
There was a problem hiding this comment.
@dotNomad is there any convention around calling something vs DTO?
There was a problem hiding this comment.
I am not sure what you mean. Are you asking about a naming convention for API response objects?
Usually I don't distinguish types in that way, and just rely on plain names (no leading I for interfaces, or DTO signifier)
dotNomad
left a comment
There was a problem hiding this comment.
I have a few concerns about how the client is setup.
| @@ -0,0 +1,19 @@ | |||
| { | |||
| "name": "@posit-dev/connect-api", | |||
There was a problem hiding this comment.
Could be a follow-up. We probably want to add this package to the dependabot groups we have to make keeping it up-to-date easier.
| }; | ||
| } | ||
|
|
||
| private async dispatch( |
There was a problem hiding this comment.
Should we add a clean-up comment for more inline-context?
packages/connect-api/src/client.ts
Outdated
| headers: { | ||
| Authorization: `Key ${options.apiKey}`, | ||
| }, | ||
| validateStatus: () => true, |
There was a problem hiding this comment.
Am I understanding this correctly that this will make every HTTP code pass? This is counter to how I'd expect to use Axios - I would expect non-200s to throw
There was a problem hiding this comment.
My understanding is that this would let the caller interpret the HTTP code - which is how I'd expect to handle it in go, but if its better to throw I'm happy to do that.
There was a problem hiding this comment.
It's the normal axios behavior to throw errors for non-200 error codes. If I was using an axios client I would definitely be surprised by that behavior.
packages/connect-api/src/client.ts
Outdated
| private async request( | ||
| method: string, | ||
| path: string, | ||
| options?: { | ||
| body?: unknown; | ||
| contentType?: string; | ||
| rawBody?: Uint8Array; | ||
| responseType?: "arraybuffer"; | ||
| }, | ||
| ): Promise<AxiosResponse> { | ||
| const headers: Record<string, string> = {}; | ||
|
|
||
| let data: unknown; | ||
| if (options?.rawBody) { | ||
| headers["Content-Type"] = | ||
| options.contentType ?? "application/octet-stream"; | ||
| data = options.rawBody; | ||
| } else if (options?.body !== undefined) { | ||
| headers["Content-Type"] = options.contentType ?? "application/json"; | ||
| data = options.body; | ||
| } | ||
|
|
||
| return this.client.request({ | ||
| method, | ||
| url: path, | ||
| headers, | ||
| data, | ||
| responseType: options?.responseType, | ||
| }); | ||
| } |
There was a problem hiding this comment.
I am a bit confused by this setup. Axios already has a client.request why wouldn't we use that by default?
I'm not seeing rawBody be used for example. Perhaps it would be clearer if that was setup in the individual API calls rather than in an abstract helper.
This also means as we used more axios features we'd have to expand this each time.
packages/connect-api/src/client.ts
Outdated
| private async requestJson<T>( | ||
| method: string, | ||
| path: string, | ||
| options?: { body?: unknown }, | ||
| ): Promise<T> { | ||
| const resp = await this.request(method, path, options); | ||
| if (resp.status < 200 || resp.status >= 300) { | ||
| const body = | ||
| typeof resp.data === "string" | ||
| ? resp.data | ||
| : JSON.stringify(resp.data ?? ""); | ||
| throw new ConnectRequestError(resp.status, resp.statusText, body); | ||
| } | ||
| return resp.data as T; | ||
| } |
There was a problem hiding this comment.
Similar note here about requestJson
I'm not sure what this is getting us.
packages/connect-api/src/client.ts
Outdated
| const errorBody = (resp.data ?? {}) as Record<string, unknown>; | ||
| const msg = (errorBody.error as string) ?? `HTTP ${resp.status}`; |
There was a problem hiding this comment.
We are needing to use as here a lot because we aren't typing the return from request. We ideally should be using an axios client get call and use the generic to type this for us to remove a lot of this as type assertions.
There was a problem hiding this comment.
ah! I see - yeah that makes sense to me. Making the swap to use generics now.
packages/connect-api/src/client.ts
Outdated
| if (resp.status < 200 || resp.status >= 300) { | ||
| const respBody = | ||
| typeof resp.data === "string" | ||
| ? resp.data | ||
| : JSON.stringify(resp.data ?? ""); | ||
| throw new ConnectRequestError(resp.status, resp.statusText, respBody); |
There was a problem hiding this comment.
Why are we doing this instead of letting the request throw default axios behavior bubble up?
packages/connect-api/src/client.ts
Outdated
| : JSON.stringify(resp.data ?? ""); | ||
| throw new ConnectRequestError(resp.status, resp.statusText, body); | ||
| } | ||
| return resp.data as BundleDTO; |
There was a problem hiding this comment.
I think we should avoid returning the data and instead type the request with a generic so we can get the response returned that has data inside of it.
packages/connect-api/src/client.ts
Outdated
| ): Promise<TaskDTO> { | ||
| let firstLine = 0; | ||
|
|
||
| for (;;) { |
There was a problem hiding this comment.
Is the same as a while true?
There was a problem hiding this comment.
they're semantically equivalent, yeah. Changing it to while (true) if that improves readability.
packages/connect-api/src/client.ts
Outdated
| const resp = await this.request("GET", `/content/${contentId}/`); | ||
| if (resp.status >= 500) { | ||
| throw new DeploymentValidationError(contentId, resp.status); | ||
| } |
There was a problem hiding this comment.
Is this why we don't use the default axios throw logic? Can we make that a one off for this API call so we don't have to have so many error catching blocks?
Let axios throw AxiosError on non-2xx responses instead of manually checking status codes and throwing custom error types. Two methods use per-request validateStatus overrides: testAuthentication inspects error response bodies, and validateDeployment accepts non-5xx codes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each method now calls this.client.request() directly with its own config, eliminating the request() and requestJson() indirection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cast through unknown on the error path so the request can use <UserDTO> generic, giving strong typing on the success path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The lockfile was missing resolved entries for platform-specific optional dependencies like @rollup/rollup-linux-x64-gnu, causing CI failures on Linux runners. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The contract tests use a file: link to @posit-dev/connect-api, so TypeScript needs axios resolvable when compiling through the linked package source. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The contract tests use a file: link to packages/connect-api, but CI only ran npm ci in the contract tests directory. TypeScript needs axios resolvable from the linked package source, so we must install packages/connect-api deps first. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dotNomad
left a comment
There was a problem hiding this comment.
I'm leaving comments to go off and do another review, but I'll do some work in the background on this and push up changes based on my comments for you to take a look at tomorrow morning @zackverham
| async testAuthentication(): Promise<UserDTO> { | ||
| const { data, status } = await this.client.request<UserDTO>({ | ||
| method: "GET", | ||
| url: "/__api__/v1/user", | ||
| validateStatus: () => true, | ||
| }); | ||
|
|
||
| if (status < 200 || status >= 300) { | ||
| const errorBody = ((data as unknown) ?? {}) as Record<string, unknown>; | ||
| const msg = (errorBody.error as string) ?? `HTTP ${status}`; | ||
| throw new Error(msg); | ||
| } | ||
|
|
||
| if (data.locked) { | ||
| throw new Error(`user account ${data.username} is locked`); | ||
| } | ||
|
|
||
| if (!data.confirmed) { | ||
| throw new Error(`user account ${data.username} is not confirmed`); | ||
| } | ||
|
|
||
| if (data.user_role !== "publisher" && data.user_role !== "administrator") { | ||
| throw new Error( | ||
| `user account ${data.username} with role '${data.user_role}' does not have permission to publish content`, | ||
| ); | ||
| } | ||
|
|
||
| return data; | ||
| } |
There was a problem hiding this comment.
The amount of TypeScript type coercion happening here makes me a bit worried. I think we can simplify a bit using axios throw behavior, but catching it here as opposed to letting it throw up like the other methods to give us those custom Errors.
async testAuthentication(): Promise<UserDTO> {
let data: UserDTO;
try {
({ data } = await this.client.get<UserDTO>("/__api__/v1/user"));
} catch (err) {
if (axios.isAxiosError(err)) {
const msg = (err.response?.data as Record<string, unknown>)?.error;
throw new Error(typeof msg === "string" ? msg : `HTTP ${err.response?.status}`);
}
throw err;
}
// custom erroring
We can do something like this which is less fragile and custom.
| /** Fetches details for a content item by ID. */ | ||
| async contentDetails(contentId: ContentID): Promise<ContentDetailsDTO> { | ||
| const { data } = await this.client.request<ContentDetailsDTO>({ | ||
| method: "GET", | ||
| url: `/__api__/v1/content/${contentId}`, | ||
| }); | ||
| return data; | ||
| } |
There was a problem hiding this comment.
This is the same as latestBundleId. Perhaps we simplify and remove latestBundleId
|
|
||
| /** Composite settings from all 7 server endpoints. */ | ||
| export interface AllSettings { | ||
| General: ServerSettings; |
There was a problem hiding this comment.
This looks like a typo. We are renaming the __api__/server_settings return, but perhaps we use a key like serverSettings. Caps is a bit strange.
| method: "GET", | ||
| url: "/__api__/v1/user", | ||
| }); | ||
| const { data: General } = await this.client.request<ServerSettings>({ |
There was a problem hiding this comment.
Note here about the odd casing.
|
|
||
| /** | ||
| * Fetches composite server settings from 7 separate endpoints, | ||
| * mirroring the Go client's GetSettings behavior. |
There was a problem hiding this comment.
I don't think so, but I'm fine starting with it to make migrations easier. Eventually I think it would be better to split this up.
We definitely should not do an await cascade like this though. We should make all of these calls in parallel with Promise.all() or something similar.
Right now this will wait for each individual API call to complete before making the next one severely slowing down this fetch.
| const { data } = await this.client.request<UserDTO>({ | ||
| method: "GET", |
There was a problem hiding this comment.
We can simplify this to client.get which is a bit more concise and easier to read IMO
| // --------------------------------------------------------------------------- | ||
|
|
||
| describe("contentDetails", () => { | ||
| const contentId = "content-guid-abc" as ContentID; |
There was a problem hiding this comment.
I think we can make a factory function to assist with reducing the number of type coercions we use in here. Very minor nit though.
| while (true) { | ||
| const { data: task } = await this.client.request<TaskDTO>({ | ||
| method: "GET", | ||
| url: `/__api__/v1/tasks/${taskId}?first=${firstLine}`, |
There was a problem hiding this comment.
We should use axios params to fill this out.
axios handles encoding and is more close to how axios wants to be used.
Intent
Implement a TypeScript Connect API client package (
@posit-dev/connect-api) and validate it against contract tests alongside the existing Go backend.Type of Change
Approach
Created a standalone
packages/connect-api/package with aConnectAPIclass that wraps the Posit Connect REST API. All 15 API methods are implemented:testAuthentication,getCurrentUsercontentDetails,createDeployment,updateDeploymentgetEnvVars,setEnvVarsuploadBundle,latestBundleId,downloadBundledeployBundle,waitForTask,validateDeploymentgetIntegrations,getSettingsKey design decisions:
axios.create()with a pre-configuredbaseURLand defaultAuthorizationheader. Each method callsthis.client.request<T>()directly with response generics for strong typing.AxiosErrorautomatically. No custom error classes. Two methods use per-requestvalidateStatusoverrides:testAuthentication(inspects error response bodies for messages) andvalidateDeployment(accepts non-5xx status codes).ContentID,BundleID,TaskID) for type safety on parametersUser Impact
None — this is preparatory code.
Automated Tests
packages/connect-api/(vitest) covering all methods, error paths, and URL constructionnpx vitest runandAPI_BACKEND=typescript npx vitest run)🤖 Generated with Claude Code